Skip to content

UI: Add cross-Dag Time Schedule view - #71558

Open
minyeamer wants to merge 28 commits into
apache:mainfrom
minyeamer:ui/time-schedule
Open

minyeamer wants to merge 28 commits into
apache:mainfrom
minyeamer:ui/time-schedule

Conversation

@minyeamer

Copy link
Copy Markdown
Contributor

Add a Time Schedule tab for comparing Dag start times and durations across an Airflow environment.

Features:

  • Day and Week timelines with overlap handling and fixed headers
  • Mean, Max, and Min aggregation within zoom-dependent time buckets
  • Pointer-anchored zoom using buttons, the mouse wheel, or arrow keys
  • Existing Dag run, tag, timetable type, and optional team filters
  • Configurable limits from the latest 200 runs through all matching runs
  • Contiguous API pagination using the configured page size
  • Gray expected-run bars for scheduled Dags without matching Dag runs
  • Readable tooltips and links to the relevant Dag or Dag run
  • Saved view, aggregation, run-limit, and scheduled-only preferences
  • Light and dark theme support

This is a UI-only change. It reuses existing endpoints and adds no schema or backend API changes.

Earlier work in #68532 and #68547 informed the time-bucket design. The view placement follows #45497, while bounded loading follows the work in #47900, #60241, and #65388.

Related: #22001


Screenshots:

  • Day view:

time_schedule-day-tooltip

  • Week view:
time_schedule-week-tooltip

Videos:

  • Zoom (Day view):
time_schedule-day-zoom_in_out.mp4
  • Zoom (Week view):
time_schedule-week-zoom_in_out.mp4
  • Dag run links (Day view):
time_schedule-day-bar_link.mp4
  • Dag run limits (Week view):
time_schedule-week-display_n_dags.mp4
  • Aggregation (Day view):
time_schedule-day-aggregation.mp4

Was generative AI tooling used to co-author this PR?
  • Yes (GPT-5.6 Sol)

Generated-by: Codex (GPT-5.6 Sol) following the guidelines


  • Read the Pull Request Guidelines for more information. Note: commit author/co-author name and email in commits become permanently public when merged.
  • For fundamental code changes, an Airflow Improvement Proposal (AIP) is needed.
  • When adding dependency, check compliance with the ASF 3rd Party License Policy.
  • For significant user-facing changes create newsfragment: {pr_number}.significant.rst, in airflow-core/newsfragments. You can add this file in a follow-up commit after the PR is created so you know the PR number.

Features:

- Add Day and Week timelines for Dag run timing patterns
- Group runs by Dag, state, time bucket, and weekday
- Support Mean, Max, and Min duration aggregation
- Show expected runs for scheduled Dags without matching runs
- Add Dag run, tag, timetable type, and team filters

Interactions:

- Add pointer-anchored timeline zoom controls
- Link timeline bars to their Dag or Dag run
- Add readable tooltips for run details
- Persist view, aggregation, limit, and filter preferences
- Support overlapping runs in Day and Week layouts

Data loading:

- Add selectable limits from 200 runs through all runs
- Paginate using the API-configured page size
- Prevent gaps between paginated Dag run results
- Limit Dag detail requests to expected-run markers

Documentation and tests:

- Document Time Schedule behavior in the UI guide
- Add light and dark screenshots
- Cover layouts, aggregation, filters, zoom, and pagination
@bbovenzi

Copy link
Copy Markdown
Contributor

Very cool! I could see a world where we could even show arrows between runs that are connected via partition keys. Or highlighting when Max active dag runs is being hit.

Love the minute detailing. That's great for dags that run every minute! Just needs a label.

  1. Let's move this as a tab inside the Dashboard home page instead of in the tabled dags/runs/tis view.
  2. Let's add the state icons to the bars so that they can be distinguished by something other than color
  3. We need labels and/or tooltips to help explain some of these filters:
    a) Latest 200 should probably be "Limit" to be consistent with other views
    b) Mean/Max/Min means nothing to me without context
  4. Day/Week modes are different types of views not a simple setting. It should probably match the tabs we have to change the calendar view on the dag details page.
  1. The filters are a bit inconsistent. We have the Filter Pill UX that is used elsewhere but separately we also have timetable filters, scheduled dags only filter, and tag filters. Let's consolidate them all together. I also recommend rebasing since @ryanahamilton's recent changes add some great examples.

@bbovenzi bbovenzi added this to the Airflow 3.4.0 milestone Aug 21, 2026

@bbovenzi bbovenzi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a cool demo. But it definitely needs some work to "productionize" the code to be usable for deployments with any degree of scale.

Comment thread airflow-core/src/airflow/ui/src/pages/TimeSchedule/useTimeScheduleData.ts Outdated
Comment thread airflow-core/docs/ui.rst Outdated
Comment thread airflow-core/src/airflow/ui/src/pages/TimeSchedule/useTimeScheduleData.ts Outdated
Comment thread airflow-core/src/airflow/ui/src/pages/TimeSchedule/useTimeScheduleData.ts Outdated
Capture changes during partial author review:

Data access and correctness:
- Avoid browser request fan-out and client-side filtering as Dag and run counts grow.
- Bound each view to 5,000 matching runs rather than allowing unbounded reads.
- Calculate only the selected Day or Week aggregation with access controls and filters applied.

Interaction quality:
- Stream timeline batches so useful data appears before the complete result is ready.
- Preserve displayed bars during zoom-driven refreshes while the final zoom level settles.
- Keep planned Dag markers visible even when no Dag runs match the current view.

Contract and verification:
- Keep the private UI OpenAPI contract and generated client aligned with the endpoint.
- Cover streaming, limits, filtering, aggregation, planned runs, and zoom behavior.
- Avoid MySQL's unsupported LIMIT subquery in an IN predicate.
- Mark NDJSON reads as sequential and fix TimeSchedule type errors.
- Confirm earlier UI import failures no longer affect asset builds.
Keep Time Schedule compatible with the shared FilterBar refactor merged from main.

- Deleted Dag list filter imports caused Vite module-resolution failures.
- The failure blocked React UI tests and CI jobs that build UI assets.
- Reuse the shared filter state for Tag and Timetable Type filtering.
@minyeamer

Copy link
Copy Markdown
Contributor Author

@bbovenzi
Thanks for the detailed review. I interpreted the four main concerns as scalability and
data-flow issues rather than only UI adjustments.

Since b13f993, excluding merge commits, I made the following changes:

  • Replaced client-side pagination and request fan-out with a dedicated streaming endpoint.
  • Removed the unbounded "All Dag runs" option and enforced a maximum of 5,000 runs on the backend.
  • Moved Dag and Dag run filtering into the backend query construction.
  • Aggregate only the selected Day or Week view on the server.
  • Stream results in batches so the UI can render progressively.
  • Updated the filter implementation after the shared FilterBar changes were merged.
  • Kept the private OpenAPI contract and generated client aligned with the backend route.

The current flow is:

flowchart TD
    UI[Time Schedule UI] --> Hook[useTimeScheduleData]
    Hook -->|One GET request| API[GET /ui/time-schedule]
    API --> Filter[Apply authorization and filters in SQL]
    Filter --> Limit[Select up to 5,000 Dag runs]
    Limit --> Batch[Process 25 Dags per batch]
    Batch --> Aggregate[Aggregate selected Day or Week view]
    Aggregate --> Stream[Return NDJSON TimeScheduleBatch]
    Stream --> Hook
    Hook --> Render[Progressively render timeline bars]
Loading

The browser now receives only the filtered data needed for the selected view, while the backend controls the result size and aggregation cost.

I initially intended this change to remain UI-only and did not plan to add a backend API. Because the review identified the scalability limitations of the original approach, I needed to first understand Airflow's backend API structure, streaming session lifecycle, authorization filters, and OpenAPI generation flow. I also spent additional time reviewing the AI-assisted code carefully, which is why the follow-up changes took about 5 days.

The implementation is now focused on the production-scale concerns from the review while preserving the existing Airflow UI/API patterns.

@minyeamer

Copy link
Copy Markdown
Contributor Author

I missed these additional suggestions in my previous follow-up. I plan to address the following:

  • Move Time Schedule into the Dashboard as a tab.
  • Add state icons to timeline bars so states are not distinguished by color alone.
  • Improve labels and tooltips:
    • Rename Latest 200 to Limit 200.
    • Add context for the duration aggregation options.
  • Replace the Day/Week selector with tabs.
  • Consolidate the remaining filters through the shared Filter Pill UX.

For the partition-key arrows, I understand this may include dependencies between Dag runs, such as those created through TriggerDagRun. However, with the timeline sorted alphabetically by dag_id, the connections could easily cross and become difficult to follow.

I’m happy to implement this with more specific guidance on which relationships to show and how they should be laid out. With the current context, I’m hesitant to add a potentially confusing visualization.


Drafted-by: GPT-5

Integrate Time Schedule into the main Dashboard stats and improve control usability, accessibility, and documentation:

Dashboard and routing:
- Add a Time Schedule `StatsCard` with `FiCalendar` icon to the Dashboard `Stats` section linking to `/time_schedule`.
- Clean up redundant layout imports in `DagsLayout.tsx`.

Control and interaction refinements:
- Switch the Day/Week view switcher to Chakra UI `Tabs` inside `TimeScheduleControls`.
- Add `ControlHelp` info tooltips (`FiInfo`) explaining Zoom controls, Dag run limit, and Duration aggregation modes.
- Refactor `TimelineBar` and `TimelineTooltip` with improved accessibility, state styling, and shared tooltip props in `constants.ts`.
- Extract common layout constants into `constants.ts` and refine date formatting utilities.

Documentation and tests:
- Add unit tests for `TimelineBar` and `timelineUtils`.
- Update `ui.rst` and capture updated light/dark mode screenshots for Time Schedule views and Dashboard stats.
@minyeamer

Copy link
Copy Markdown
Contributor Author

@bbovenzi I've addressed the remaining Time Schedule suggestions and wrapped up the final refinements.

Dashboard entry point

Time Schedule is no longer surfaced from the Dags area. It is now available from the Stats section on the Dashboard home page.

home_stats

State icons on timeline bars

Timeline bars now include the shared Airflow state icons alongside their labels, so Dag run state is distinguishable without relying on color alone. The same icon treatment is also used in the bar tooltip.

Screenshot 2026-09-06 at 8 58 44 PM

Labels and tooltips

  • Latest 200 is now Limit 200.
  • The aggregation choices now use descriptive names: Average duration, Full time range, and Shortest run.
  • Added contextual help for zoom controls, the Dag run limit, and duration aggregation.
Screenshot 2026-09-06 at 8 59 19 PM Screenshot 2026-09-06 at 9 00 21 PM Screenshot 2026-09-06 at 9 00 30 PM

Day and Week as view tabs

Day and Week are now presented as tabs rather than as a simple setting, matching the interaction pattern used for calendar-style view changes elsewhere in the UI.

Screenshot 2026-09-06 at 9 01 31 PM

Documentaion

I also updated the UI documentation and screenshots to reflect the Dashboard entry point and the current Time Schedule controls.

@bbovenzi bbovenzi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please have your agent check for pre-existing code instead of inventing its own practices.

I still don't think our endpoint is structured the right way to make this scale properly. . Today we are fetching every single dag with no pagination and no default filter. We have an API roundtrip for every zoom level change but have no auto refresh. We have no autorefresh support. The UI has no virtualization either. Teams can have thousands of dags.

Feel free to start a chat on slack. There is a lot of API design and UX design to discuss.

Comment on lines +113 to +150
<Tabs.Root
data-testid="time-schedule-view-mode"
onValueChange={({ value }) => {
if (value === "day" || value === "week") {
onViewModeChange(value);
}
}}
size="sm"
value={viewMode}
variant="line"
>
<Tabs.List
bg="bg.muted"
borderColor="border.subtle"
borderRadius="md"
borderWidth="1px"
gap={1}
p={1}
>
{(["day", "week"] as const).map((mode) => (
<Tabs.Trigger
_hover={{ bg: "bg.subtle", color: "fg" }}
_selected={{ bg: "brand.muted", color: "fg", fontWeight: "bold" }}
borderRadius="sm"
color="fg.muted"
fontSize="md"
fontWeight="medium"
height="32px"
justifyContent="center"
key={mode}
value={mode}
width="88px"
>
{translate(`timeSchedule.${mode}`)}
</Tabs.Trigger>
))}
</Tabs.List>
</Tabs.Root>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use our pre-existing ButtonGroupToggle component here instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image
import { ButtonGroupToggle } from "src/system-components";
Suggested change
<Tabs.Root
data-testid="time-schedule-view-mode"
onValueChange={({ value }) => {
if (value === "day" || value === "week") {
onViewModeChange(value);
}
}}
size="sm"
value={viewMode}
variant="line"
>
<Tabs.List
bg="bg.muted"
borderColor="border.subtle"
borderRadius="md"
borderWidth="1px"
gap={1}
p={1}
>
{(["day", "week"] as const).map((mode) => (
<Tabs.Trigger
_hover={{ bg: "bg.subtle", color: "fg" }}
_selected={{ bg: "brand.muted", color: "fg", fontWeight: "bold" }}
borderRadius="sm"
color="fg.muted"
fontSize="md"
fontWeight="medium"
height="32px"
justifyContent="center"
key={mode}
value={mode}
width="88px"
>
{translate(`timeSchedule.${mode}`)}
</Tabs.Trigger>
))}
</Tabs.List>
</Tabs.Root>
<ButtonGroupToggle<ViewMode>
data-testid="time-schedule-view-mode"
marginStart="auto"
onChange={onViewModeChange}
options={[
{ label: translate("timeSchedule.day"), value: "day" },
{ label: translate("timeSchedule.week"), value: "week" },
]}
size="sm"
value={viewMode}
/>

Comment on lines +98 to +114
export const formatDurationLabel = (durationMs: number) => {
if (durationMs <= 0) {
return "";
}
const seconds = Math.max(1, Math.round(durationMs / 1000));

if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);

if (minutes < 60) {
return `${minutes}m`;
}

return minutes % 60 > 0 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${Math.floor(minutes / 60)}h`;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not create a new formatDuration function and use our existing one in datetimeUtils.ts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getTimelineDurationSeconds keeps bar labels compact while reusing Airflow’s shared renderDuration formatter: seconds are retained for sub-minute runs (50s) but omitted for longer runs (6m, 1h 2m).

import { renderDuration } from "src/utils/datetimeUtils";
Suggested change
export const formatDurationLabel = (durationMs: number) => {
if (durationMs <= 0) {
return "";
}
const seconds = Math.max(1, Math.round(durationMs / 1000));
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes}m`;
}
return minutes % 60 > 0 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${Math.floor(minutes / 60)}h`;
};
export const getTimelineDurationSeconds = (durationMs: number) =>
durationMs >= 60_000 ? Math.floor(durationMs / 60_000) * 60 : durationMs / 1000;
export const getTimelineBarMinimumWidth = (durationMs: number, locale?: string) =>
STATE_ICON_AND_SPACING_WIDTH_PX +
(durationMs > 0 ? (renderDuration(getTimelineDurationSeconds(durationMs), locale)?.length ?? 0) : 0) *
DURATION_CHARACTER_WIDTH_PX;

Comment on lines +18 to +26
*/
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";

dayjs.extend(utc);
dayjs.extend(timezone);

export { default as dayjs } from "dayjs";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file isn't necessary

const formatTime = (datetime: string | null, selectedTimezone: string) =>
datetime === null ? "—" : dayjs(datetime).tz(selectedTimezone).format("HH:mm");

export const TimelineTooltip = ({ item, selectedTimezone }: TimelineTooltipProps) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these tooltips are styled different from the rest of the UI. Let's please make this more consistent.

Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image
airflow-core/src/airflow/ui/src/pages/TimeSchedule/TimelineBar.tsx

- <Tooltip content={renderTooltip(item)} contentProps={TIMELINE_TOOLTIP_CONTENT_PROPS}>
+ <Tooltip content={renderTooltip(item)}>

Comment on lines +33 to +34
const formatTime = (datetime: string | null, selectedTimezone: string) =>
datetime === null ? "—" : dayjs(datetime).tz(selectedTimezone).format("HH:mm");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have a format date and format time functions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import { formatDate } from "src/utils/datetimeUtils";
Suggested change
const formatTime = (datetime: string | null, selectedTimezone: string) =>
datetime === null ? "—" : dayjs(datetime).tz(selectedTimezone).format("HH:mm");
const formatTime = (datetime: string | null, selectedTimezone: string) =>
datetime === null ? "—" : formatDate(datetime, selectedTimezone, "HH:mm");

state: item.state,
});

export const useTimeScheduleData = ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of this is similar to useGridTiSummariesStream. Let's try to extract out some reusable functions.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That + react query should also make it easier to add auto refresh. We should probably think about how to stream this info with auto refresh in a lightweight manner instesd of always appending info.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I switched to React Query’s streamedQuery and Airflow’s existing useAutoRefresh:

import { experimental_streamedQuery as streamedQuery, useQuery } from "@tanstack/react-query";
import { useAutoRefresh } from "src/utils";
export const useTimeScheduleData = (streamQuery: string) => {
  const refetchInterval = useAutoRefresh({});
  const nonZoomQuery = new URLSearchParams(streamQuery);

  nonZoomQuery.delete("time_scale");
  const nonZoomStreamQuery = nonZoomQuery.toString();
  const query = useQuery({
    placeholderData: (previousData, previousQuery) =>
      previousQuery?.queryKey[1] === nonZoomStreamQuery ? previousData : undefined,
    queryFn: streamedQuery<TimeScheduleBatch>({
      refetchMode: "replace",
      streamFn: ({ signal }) => streamTimeSchedule(streamQuery, signal),
    }),
    queryKey: ["time-schedule", nonZoomStreamQuery, streamQuery],
    refetchInterval,
    refetchIntervalInBackground: false,
    retry: false,
  });
  ...

I’ll follow up separately with questions about further improvements.

Comment on lines +44 to +56
const buildTimelineItem = (item: TimeScheduleItem): TimelineItem => ({
dagId: item.dag_id,
dagRunId: item.dag_run_id,
durationMs: item.duration_ms,
endDate: item.end_date,
isPlaceholder: item.is_placeholder,
isPlanned: item.is_planned,
isTimeScheduled: item.is_time_scheduled,
label: item.label,
runCount: item.run_count,
startDate: item.start_date,
state: item.state,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's just use the API's snake_casing. No need to maintain our own camelCasing conversion

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed buildTimelineItem and now use the generated API types directly, preserving snake_case fields.

deleted:

const buildTimelineItem = (item: TimeScheduleItem): TimelineItem => ({ ... });

  useEffect(() => {
    ...
    const applyBatch = (batch: TimeScheduleBatch, shouldReplaceTimelineItems: boolean) => {
      const batchItems = batch.items.map(buildTimelineItem);
      ...

added:

export const useTimeScheduleData = (streamQuery: string) => {
  ...
  const query = useQuery({ ... });

  return {
    dagRunCount: query.data?.reduce((count, batch) => count + batch.dag_run_count, 0) ?? 0,
    error: query.error,
    isLoading: query.isPending,
    timelineItems: query.data?.flatMap((batch) => batch.items) ?? [],
  };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file should should be moved to src/queries

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved useTimeScheduleData.ts from src/pages/TimeSchedule to src/queries and updated the import.

airflow-core/src/airflow/ui/src/queries
├── useTimeScheduleData.test.tsx
└── useTimeScheduleData.ts

Comment on lines +71 to +72
const [error, setError] = useState<Error>();
const [isLoading, setIsLoading] = useState(true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use react query at least a little bit instead of handling loading and error on our own.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced the manual loading and error state with react query’s query.isPending and query.error.

export const useTimeScheduleData = (streamQuery: string) => {
  ...
  const query = useQuery({ ... });

  return {
    ...
    error: query.error,
    isLoading: query.isPending,
    ...
  };

is_placeholder: bool
is_planned: bool
is_time_scheduled: bool
label: str

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not rename dag_display_name

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

class TimeScheduleItem(BaseModel):
    """An aggregated bar in the Time Schedule UI."""

    dag_id: str
    dag_run_id: str
    duration_ms: float
    end_date: datetime | None
    is_placeholder: bool
    is_planned: bool
    is_time_scheduled: bool
-   label: str
+   dag_display_name: str
    run_count: int
    start_date: datetime | None
    state: DagRunState | Literal["placeholder", "planned"]

- Use React Query streaming and auto-refresh for Time Schedule data.
- Apply scheduled-only filtering through the shared FilterPill and API.
- Clarify duration labels and keep bar widths consistent with their labels.
- Align tooltips, zoom controls, and Day/Week tabs with Airflow UI patterns.
- Update routing, generated API types, documentation, translations, and tests.
- Remove Time Schedule from the Dag stats cards
- Add a separate linked card beside Stats on the Dashboard
- Update the UI documentation and add card navigation tests
- Limit displayed Dags to those represented by the selected runs
- Virtualize Day view rows for larger run limits
- Link aggregated bars to filtered Dag Runs lists
- Add Start Time ranges and multi-select Start Weekday filters
- Provide timezone context required by the new time filters
- Format the Dag run limit counter according to locale conventions
- Align the limit option test with formatted number output

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants